﻿// ===============================================================================
// Router.cs
// EOS Web Framework
// ===============================================================================
// Copyright (c) EOS Development Group. All rights reserved.
// ===============================================================================
namespace EOS.Framework.Web.Routing
{
    using System;
    using System.Collections.Generic;
    using System.Globalization;
    using System.Linq;
    using System.Text.RegularExpressions;
    using EOS.Framework.Contracts.Web.Routing;
    using EOS.Framework.Tools.Aspects;
    using EOS.Framework.Tools;

    /// <summary>
    /// Default implementation of the IRouter Contract.
    /// </summary>
    public class Router : IRouter
    {
        /// <summary>
        /// Defines how a parameter must start.
        /// </summary>
        public const string ParameterExpressionBegin = "[";
        /// <summary>
        /// Array if all characters which seperate a path part from another.
        /// </summary>
        private static readonly char[] _routePathSeparators = new char[] { '/', '.' };

        /// <summary>
        /// Collection of all routes.
        /// </summary>
        private RouteDictionary _routes;

        /// <summary>
        /// Returns a list of all routes for this router.
        /// </summary>
        /// <value>The routes.</value>
        public RouteDictionary Routes
        {
            get 
            {
                if (_routes == null)
                {
                    _routes = new RouteDictionary();
                }
                return _routes; 
            }
        }

        #region IRouter Member

        /// <summary>
        /// Finds the best route from all matching routes in the "routes" parameter.
        /// Throws an NotRouteFoundException if there is no matching route.
        /// </summary>
        /// <param name="url">The URL. Cannot be null.</param>
        /// <param name="applicationPath">The application path. Cannot be null and must start with "/".</param>
        /// <returns>The best route.</returns>
        /// <example>
        /// 	<code>
        ///     router.Routes.Add(new Route("/Products/[id]", typeof(MyRouteHandler));
        ///     MatchingRoute matchingRoute = router.FindBestRoute(new Uri("http://www.mysite.de/Products/123"), "/");
        /// 
        ///     int id = int.Parse(matchingRoute.Parameters["id"]);
        /// </code>
        /// </example>
        /// <exception cref="ArgumentNullException">
        /// 	<para><paramref name="url"/> is NULL (Nothing in Visual Basic).</para>
        /// 	<para>- or -</para>
        /// 	<para><paramref name="applicationPath"/> is NULL (Nothing in Visual Basic).</para>
        /// </exception>
        /// <exception cref="ArgumentException">
        /// 	<para><paramref name="applicationPath"/> is an invalid  ApplicationPath.</para>
        /// 	<para>- or -</para>
        /// 	<para><paramref name="url"/> is a string of length 0.</para></exception>
        /// <exception cref="NoRouteFoundException">If there is no matching route.</exception>
        /// <exception cref="InvalidRouteException">Thrown, if one of the routes is not valid.</exception>
        [ParamNotNull("url")]
        public MatchingRoute FindBestRoute(Uri url, string applicationPath)
        {
            int lastSlash = url.AbsolutePath.LastIndexOf("/", StringComparison.CurrentCulture);
            int lastDot = url.AbsolutePath.LastIndexOf(".", StringComparison.CurrentCulture);

            // If the url ends with a file extension, which is not like htm, html or aspx
            // that means we dont handle the route, because it is a file request, e.g. for an image.
            if (lastDot > lastSlash)
            {
                string fileType = url.AbsolutePath.Substring(lastDot + 1).ToLower(CultureInfo.CurrentCulture);
                if (fileType.Equals("htm") == false &&
                    fileType.Equals("html") == false && 
                    fileType.Equals("aspx") == false)
                {
                    return null;
                }
            }

            // Validate the application path and the routes.
            // If they dont pass our test we throw an exception.
            ValidateApplicationPath(applicationPath);
            ValidateRoutes();

            return FindMatchingRoute(url, applicationPath);
        }

        #endregion

        #region Private Helber Member

        /// <summary>
        /// Finds the best matching route for the specified url and 
        /// the specified applicationPath.
        /// </summary>
        /// <param name="url">The url for which we try to find a matching route.</param>
        /// <param name="applicationPath">The application path.</param>
        /// <returns>The best matching route if found, otherwise null
        /// (Nothing in Visual Basic).</returns>
        private MatchingRoute FindMatchingRoute(Uri url, string applicationPath)
        {
            MatchingRoute bestRoute = null;
            RoutePriority bestPriority = RoutePriority.Low;

            string[] urlArray = GetVirtualPathArray(url, applicationPath);
            
            // Loop through each route in our list.
            foreach (Route currentRoute in _routes.Values)
            {
                // Count the number of matching parts.
                int matchCount = 0;

                MatchingRoute temp = new MatchingRoute();
                temp.Handler = (IRouteHandler)Activator.CreateInstance(currentRoute.Handler);
                temp.Url = currentRoute.Url;

                string[] routeUrlArray = SplitRouteUrl(currentRoute);
                if (urlArray.Length <= routeUrlArray.Length)
                {
                    // Loop through each part our url. A url part can be seperated
                    // by a dot or a backslash
                    for (int i = 0; i < routeUrlArray.Length; i++)
                    {
                        string currentUrlArrayPart = routeUrlArray[i].Trim();

                        // If the index of our current route part is lower,
                        // than the maximum number if parts of the current url,
                        // this means, that the current part can either be just a constant
                        // value or a parameter.
                        if (i < urlArray.Length)
                        {                           
                            // Check if the current part is constant value.                              
                            if (currentUrlArrayPart.StartsWith(ParameterExpressionBegin, StringComparison.CurrentCulture) == false)
                            {
                                // The part is a contanst value, because it does not begins with
                                // the specified parameter character ("[" at the moment)
                                if (urlArray[i].ToLower(CultureInfo.CurrentCulture).Equals(routeUrlArray[i].ToLower(CultureInfo.CurrentCulture)))
                                {
                                    // If the route part is equals to the url
                                    // this part matchs and we increase the counter.
                                    matchCount++;
                                }
                            }
                            else
                            {
                                // The current part is a parameter, because it begins
                                // with the specified parameter character ("[" at the moment) 
                                if (currentUrlArrayPart.StartsWith(ParameterExpressionBegin, StringComparison.CurrentCulture))
                                {
                                    string parameterName = currentUrlArrayPart.Substring(1, currentUrlArrayPart.Length - 2);
                                    object value = urlArray[i];

                                    // If the url part is a valid value for the current 
                                    // parameter we increase the counter.
                                    if (VerifyParameter(currentRoute, parameterName, value))
                                    {
                                        temp.Parameters.Add(parameterName, value);
                                        matchCount++;
                                    }
                                }
                            }
                        }
                        else
                        {
                            // The index of the current part is bigger, that the
                            // number of url parts, this means constant values cannot match, but parameters
                            // can, if there is a default value set.
                            if (currentUrlArrayPart.StartsWith(ParameterExpressionBegin, StringComparison.CurrentCulture))
                            {
                                string parameterName = currentUrlArrayPart.Substring(1, currentUrlArrayPart.Length - 2);
                                if (currentRoute.Defaults.ContainsKey(parameterName))
                                {
                                    // There is default value for the current parameter.
                                    object value = currentRoute.Defaults[parameterName];

                                    if (VerifyParameter(currentRoute, parameterName, value))
                                    {
                                        // If the default value matchs to our parameter
                                        // we increase the counter.
                                        temp.Parameters.Add(parameterName, value);
                                        matchCount++;
                                    }
                                }
                            }
                        }
                    }

                    // If the counter has the same size as the length or router parts, this
                    // means all parts match to the url and if there is no route set before
                    // with a higher priority we set the current route as best matching route.
                    if (matchCount == routeUrlArray.Length && (bestRoute == null || currentRoute.Priority > bestPriority))
                    {
                        bestRoute = temp;
                        bestPriority = currentRoute.Priority;

                        // Copy all parameter names and their values
                        // from the current route.
                        foreach (string parameterKey in currentRoute.Defaults.Keys)
                        {
                            if (bestRoute.Parameters.ContainsKey(parameterKey) == false)
                            {
                                bestRoute.Parameters.Add(parameterKey, currentRoute.Defaults[parameterKey]);
                            }
                        }
                    }
                }
            }

            return bestRoute;
        }

        /// <summary>
        /// Verifies, if the the specified value fits the parameter with the
        /// the specified name of the target route.
        /// </summary>
        /// <param name="route">The route, which contains the parameter.</param>
        /// <param name="parameterName">Name of the parameter.</param>
        /// <param name="value">The value, which must fit.</param>
        /// <returns>True, if the the value fits to the parameter, otherwise false.</returns>
        private static bool VerifyParameter(Route route, string parameterName, object value)
        {
            bool result = true;

            // If the parameter does not have a contraint the format
            // of the value doesnt matter, buf if so, the value
            // must match to the constraint, which will be defined
            // as regular expression (regex).
            if (route.Constraints.ContainsKey(parameterName))
            {
                string valueString = value.ToString();
                string pattern = "^(" + route.Constraints[parameterName] + ")$";

                result = Regex.IsMatch(valueString ?? string.Empty, pattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
            }

            return result;
        }

        /// <summary>
        /// Gets the virtual path array of the specified url.
        /// The virtual path is the absolute path of the url, but does not contains
        /// the specified applicationPath.
        /// </summary>
        /// <param name="url">The url for which the virtual path should be extracted.</param>
        /// <param name="applicationPath">The application path.</param>
        /// <returns>The virtual path of the url as simple string.</returns>
        private static string GetVirtualPath(Uri url, string applicationPath)
        {
            string path = string.Empty;

            if (applicationPath.Equals("/"))
            {            
                // If the the specified application path is just equals to "/"
                // we just return the absolut path.
                path = url.AbsolutePath;
            }
            else
            {
                // Otherwise we return the absolute path minus
                // the application path.
                path = url.AbsolutePath.Substring(applicationPath.Length + 
                    (url.AbsolutePath.EndsWith("/") ? 1 : 0));
            }
            return path.Trim();
        }

        /// <summary>
        /// Gets the virtual path array of the specified url as array of string elements.
        /// The virtual path is the absolute path of the url, but does not contains
        /// the specified applicationPath.
        /// </summary>
        /// <param name="url">The url for which the virtual path should be extracted.</param>
        /// <param name="applicationPath">The application path.</param>
        /// <returns>The virtual path of the url as array of string elements.</returns>
        private static string[] GetVirtualPathArray(Uri url, string applicationPath)
        {            
            // Create a temporary list, because we dont know the number of 
            // items yet and therefore cannot use an array with constant size.
            List<string> path = new List<string>();

            foreach (string pathElement in GetVirtualPath(url, applicationPath).Split(_routePathSeparators))
            {               
                // The array should only contains elements who are 
                // not null and not empty.
                if (string.IsNullOrEmpty(pathElement) == false)
                {
                    path.Add(pathElement);
                }
            }
            return path.ToArray();
        }

        /// <summary>
        /// Splits the url of the specified url into parts and 
        /// returns the parts as array of strings. The route will be splitted,
        /// whenever a '.' or a '/' is found.
        /// </summary>
        /// <param name="route">The route, which url should be splitted.</param>
        /// <returns>The array of string parts of the specified url.</returns>
        private static string[] SplitRouteUrl(Route route)
        {
            // Create a temporary list, because we dont know the number of 
            // items yet and therefore cannot use an array with constant size.
            List<string> path = new List<string>();

            foreach (string pathElement in route.Url.Split(_routePathSeparators))
            {
                // The array should only contains elements who are 
                // not null and not empty.
                if (string.IsNullOrEmpty(pathElement) == false)
                {
                    path.Add(pathElement);
                }
            }
            return path.ToArray();
        }

        /// <summary>
        /// Validates the application path parameter.
        /// </summary>
        /// <param name="applicationPath">The application path to validate.</param>
        private static void ValidateApplicationPath(string applicationPath)
        {
            // The application path cannot be null.
            Guard.NotNullOrEmpty(applicationPath, "applicationPath");

            // Verifies, that the application path begins with a "/"
            if (applicationPath.StartsWith("/", StringComparison.CurrentCulture) == false && applicationPath.Equals("/") == false)
            {
                throw new ArgumentException(string.Format(CultureInfo.CurrentUICulture,
                    "Application has wrong format. Must begin with a \"/\""));
            }
        }

        /// <summary>
        /// Validates the list of routes. There must be one or more
        /// route and all routes must have valid properties.
        /// </summary>
        private void ValidateRoutes()
        {
            // If routes is null, it means, that 
            // there is no added route.
            if (_routes == null)
            {
                throw new InvalidOperationException(
                    "No route found for this url, because there are no rules defined.");
            }

            // Verifies that one or more routes are 
            // added to the list.
            if (_routes.Count == 0)
            {
                throw new InvalidOperationException(
                    "No route found for this url, because there are no rules defined.");
            }

            foreach (Route route in _routes.Values)
            {
                // Route url cannot be null or empty
                // and cannot contain only blanks.
                Guard.NotNullOrEmpty(route.Url, "route.Url");
                
                // There must be a valid route handler specified.
                Guard.NotNull(route.Handler, "route.handler");

                if (route.Handler.GetInterfaces().Contains(typeof(IRouteHandler)) == false)
                {
                    throw new ArgumentException(string.Format(CultureInfo.CurrentUICulture,
                        "Type must be subclass of type {0}.", typeof(IRouteHandler).Name));
                }
            }
        }

        #endregion
    }
}
